EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / DirectAdmin / Writing to DirectAdmin Through Panel Forms When the API Only Reads
DirectAdmin

Writing to DirectAdmin Through Panel Forms When the API Only Reads

by Ton-Or ·14 Oct 2026·9 min read
Writing to DirectAdmin Through Panel Forms When the API Only Reads

The API commands supplied by the vendor do not cover everything the control panel can do. When I need to write settings without an official endpoint to support it, I have to drive the panel forms directly. It is workable, but I need firm rules in place before running it for real.

I was building a back-office system that needed to let admins edit hosting packages from the app's own web interface, without switching to another screen to open DirectAdmin. During planning, I thought it would be straightforward because DirectAdmin had CMD_API_* for everything. Once I started implementing it, I discovered that user packages only had read operations, with no official endpoints for creating or editing them. The only remaining option was to POST directly to the panel forms, just as a browser does. That works, but it requires different precautions from a normal API call because the vendor gives no guarantee that the fields will stay the same, and there are no straightforward response codes to check.

The hosts, ports, package names, and usernames in this article are all made up. The endpoint names and field sets, however, are real ones I have tested.

First, separate what can be read from what needs form submission

I wasted quite a bit of time guessing that there must be an endpoint I had not found yet. Eventually, I went through them one by one and made a table.

Official endpoint available

Returns data I can parse

List all packages using CMD_API_PACKAGES_USER?action=list&creator=admin. It returns line-separated url-encoded text that is easy to parse. I can rely on the format because the vendor documents it as an API.

No endpoint; drive the form directly

Returns a page meant for people

View package settings using CMD_SHOW_USER_PACKAGE?package=ชื่อ, which returns HTML I have to parse myself · Create or edit a package by POSTing to CMD_MANAGE_USER_PACKAGES, which returns 302 followed by a full HTML page · Delete a package using the same endpoint, with an additional confirmation flag

A clue that keeps me on track

Names beginning with CMD_API_ are a contract with machines, returning key-value pairs that can be parsed. Names beginning with just CMD_ are pages for people, returning HTML. If the endpoint you need does not have API in its name, you are about to drive a human-facing interface rather than call an API. Know that from the start.

Always try a request with curl before writing the actual code, to see what the response looks like and confirm that you have sufficient permissions.

# ฝั่งอ่าน - ตอบกลับเป็น url-encoded ใช้ได้เลย
curl -sS -u "admin:<DA-LOGIN-KEY>" \
  "https://panel.example.com:2222/CMD_API_PACKAGES_USER?action=list&creator=admin"

# ฝั่งเขียน - ตอบกลับเป็น HTML ทั้งหน้า ไม่มีรหัสสถานะให้เช็คง่ายๆ
curl -sS -u "admin:<DA-LOGIN-KEY>" -L \
  -d "packagename=demo-basic" -d "add=Save" -d "skin=enhanced" -d "language=en" \
  "https://panel.example.com:2222/CMD_MANAGE_USER_PACKAGES" | head -40

I use a login key created through the panel instead of the actual admin password, and restrict the key to only the commands I need. If the code or logs leak, the damage is limited to that scope. The key is read entirely from the environment, with not a single line containing it in the source.

Send the entire form, not just the fields you want to change

The biggest time sink was that I initially sent only the changed fields, then wondered why all the other values disappeared. The panel form does not work like PATCH. It works as "send the entire form and overwrite." Any field left out is treated as disabled or zero.

Form rulesRead existing valuesSHOW_USER_PACKAGE->Combine with new valuesmerge in the app->POST the full setEvery field->Read back to verifyre-list

There are four required items that cannot be left out. Omit any one of them and the form will either do nothing silently or work only partially.

  1. packagename The package name, sent every time for both creation and editing.
  2. add=Save The button the panel expects to have been pressed. Without it, the panel does not know what to do.
  3. skin and language The panel uses these to choose the page it returns. Some versions return 500 if they are missing.
  4. package=<name> Add this only when "editing" to tell it to overwrite the existing entry. Leaving it out means creating a new one.

I wrote the app's form builder like this, always pairing numeric values with their unlimited switches.

private function buildForm(array $resources, bool $isUpdate, string $name): array
{
    $limits   = $resources['limits']    ?? [];
    $unlim    = $resources['unlimited'] ?? [];
    $features = $resources['features']  ?? [];

    $form = [
        'packagename' => $name,
        'skin'        => 'enhanced',
        'language'    => 'en',
        'add'         => 'Save',
    ];

    // มี package= อยู่ในฟอร์ม = แผงตีความว่าแก้ของเดิม ไม่มี = สร้างใหม่
    if ($isUpdate) {
        $form['package'] = $name;
    }

    // [ชื่อฟิลด์ในฟอร์ม, คีย์ค่าตัวเลข, คีย์สวิตช์ไม่จำกัด]
    $pairs = [
        ['bandwidth', 'bandwidth_mb', 'bandwidth'],
        ['quota',     'quota_mb',     'quota'],
        ['inode',     'inode',        'inode'],
        ['vdomains',  'vdomains',     'vdomains'],
        ['nemails',   'nemails',      'nemails'],
        ['mysql',     'mysql',        'mysql'],
        ['ftp',       'ftp',          'ftp'],
    ];

    foreach ($pairs as [$field, $limitKey, $unlimKey]) {
        // ส่งค่าตัวเลขเสมอ แม้จะตั้งไม่จำกัด เพื่อให้สแนปช็อตในฐานเราตรงกับที่ส่งไป
        $form[$field] = (string) (int) ($limits[$limitKey] ?? 0);
        if (! empty($unlim[$unlimKey])) {
            $form['u' . $field] = 'ON';    // u + ชื่อฟิลด์ = ไม่จำกัด
        }
    }

    // สวิตช์เปิด/ปิดฟีเจอร์: ส่งเฉพาะตอนเปิด ค่าเป็น ON เท่านั้น
    foreach (['cgi', 'php', 'ssl', 'ssh', 'cron', 'catchall', 'spam'] as $f) {
        if (! empty($features[$f])) {
            $form[$f] = 'ON';
        }
    }

    return $form;
}
The only value to send for a switch is ON

Do not send ssl=OFF, ssl=0, or ssl=false, because the panel does not read the value. It only checks "is this key present in the form," following HTML checkbox conventions. Sending any value other than ON may still be interpreted as enabled. To disable it, remove the key from the form entirely. Unlimited values use the prefix u, such as uquota=ON, and the panel ignores the accompanying numeric value. I still send it so that the snapshot on my side stays consistent.

Never determine success from the response

This is the rule I want to emphasize most in this article. At first, I did what everyone does: wrote a regex to match the word saved or a class named info in the returned HTML, then concluded that it had succeeded. The problem is that the panel form returns 302, and the HTTP client follows the redirect automatically. What I get back is the entire next page, containing the word saved, a class named info, and everything the regex could match, even when my command has been silently rejected.

Back-office appDirectAdmin panelPOST CMD_MANAGE_USER_PACKAGES302 redirects to a full HTML pageNo verdict here: saved can appear on failureGET CMD_API_PACKAGES_USER?action=listurl-encoded package listName listed or read-back values match = successUse regex only to extract failure reasons for the admin
The POST returns a full HTML page that cannot establish the result. The actual result must come from calling the read endpoint again.
What I did initially

Guess the result from page text

Match the word saved or the info class in the response body. This periodically produced false "success" results because the redirect destination normally contained those words anyway. My database then recorded the package as synced even though nothing on the panel had changed.

What I use now

Send the request, then read the actual state again

After the POST, discard the body and call the read endpoint again. Only count it as successful if the package name appears in the list (for creation) or the values read back match what was sent (for editing). Use regex only to extract an explanation for the admin when something fails.

$body = $this->da->postRawForm('CMD_MANAGE_USER_PACKAGES', $form);

// ตัวตัดสินคือการอ่านกลับ ไม่ใช่เนื้อหาใน $body
if ($this->existsOnDa($name)) {
    $product->forceFill([
        'da_sync_status' => 'synced',
        'da_synced_at'   => now(),
        'da_sync_error'  => null,
    ])->save();

    return ['ok' => true, 'message' => "แพ็ก {$name} ซิงก์แล้ว (ยืนยันด้วยการอ่านกลับ)"];
}

// ถึงตรงนี้คือล้มจริง ค่อยแกะ $body มาทำข้อความบอกสาเหตุ
$parsed = $this->parseResponse($body);
Log::warning('da package sync failed', [
    'package'  => $name,
    'response' => mb_substr($body, 0, 2000),   // เก็บไว้ให้คนไล่ต่อได้
]);

Another case to think through is "reading back does not find it either." I chose to have the existence check fall back to creation: if the check cannot complete because of a network interruption or a slow panel response, assume it does not exist and send a create command. The reason is that if it does exist, the panel will reject the request with a duplicate-name message, which does no damage. But if I fall back to editing and it does not actually exist, I will be sending a command to overwrite something nonexistent, with unpredictable results.

Pull the settings first; do not overwrite fields you do not recognize

The panel form always has more fields than my app knows about. New panel versions keep adding features. If I send only the fields I recognize, every unrecognized field will be reset. To prevent that, I have a command that pulls the settings from the panel and stores them first, then merges in the new values the admin has edited before sending the entire set back.

public function pull(Product $product): array
{
    $this->assertSyncAllowed();
    $name = $this->requirePackageName($product);

    $body      = $this->da->getRaw('CMD_SHOW_USER_PACKAGE', ['package' => $name]);
    $resources = $this->parseShowPackage($body);

    if ($resources === null) {
        // แกะไม่ออก = อย่าเดา อย่าเขียนค่าว่างทับของเดิม
        Log::warning('da package pull parse failed', [
            'package' => $name,
            'body'    => mb_substr($body, 0, 2000),
        ]);
        return ['ok' => false, 'message' => "แกะหน้า CMD_SHOW_USER_PACKAGE ของ {$name} ไม่ออก ดู log"];
    }

    $product->forceFill([
        'da_resources'   => $resources,
        'da_sync_status' => 'synced',
        'da_synced_at'   => now(),
    ])->save();

    return ['ok' => true, 'resources' => $resources];
}

My HTML parser uses regex exclusively, which is certainly fragile. The rule I established is if a field cannot be parsed, fall back to the value already in my database, rather than overwriting it with zero. And if the entire page cannot be parsed at all, such as when the panel returns a login page because the key has expired, treat the whole operation as failed and write nothing to the database. Saving empty values and sending them back on the next attempt would amount to wiping the customer's settings with my own hands.

Deletion needs two layers of confirmation, and must never be automatic

Deletion uses the same form but a different set of fields. It requires a delete flag, a confirmation button, and the index of the item to delete. I made this a command that the admin must trigger manually. There is no path in the system that can delete packages automatically.

$body = $this->da->postRawForm('CMD_MANAGE_USER_PACKAGES', [
    'delete'    => 'yes',
    'confirmed' => 'Confirm',   // ขาดตัวนี้ แผงจะเด้งหน้าถามยืนยันแทนที่จะลบ
    'delete0'   => $name,       // ลำดับเริ่มที่ศูนย์ ลบทีละรายการ
]);

// ลบก็เหมือนเดิม: ยืนยันด้วยการอ่านกลับ ไม่ใช่ดูข้อความในหน้า
if (! $this->existsOnDa($name)) {
    $product->forceFill(['da_sync_status' => 'unsynced', 'da_synced_at' => null])->save();
    return ['ok' => true, 'message' => "ลบแพ็ก {$name} ออกจากแผงแล้ว"];
}
return ['ok' => false, 'message' => "แพ็ก {$name} ยังอยู่บนแผง ลบไม่สำเร็จ"];
Deleting a package does not delete accounts using it

Existing accounts retain the values copied when they were created. Deleting the package later does not change those accounts. The confusion comes later when someone asks which package a customer is on and cannot find it because the package has been deleted. I therefore choose to rename the package with a "retired" prefix instead of actually deleting it, unless it was created by mistake and no account has ever used it.

Two kill switches, separated by the extent of the damage

A system that sends write commands to a real server's control panel must always have a way to shut them off. But I do not use a single switch to control everything, because these two kinds of work have very different consequences when something goes wrong.

PackagesAffect defaults
AccountsAffect customers directly
DENYDefault for both
2 layersEnable independently

The first switch controls configuration work, such as creating or editing packages. A mistake affects "the defaults for accounts created from now on," leaving some time to fix it. The second switch controls work that touches actual user accounts, such as suspending accounts, changing passwords, or deleting them. A mistake takes a customer's website down immediately. I use separate variables and disable both by default.

// config/services.php
'directadmin' => [
    'base_url'  => env('DA_BASE_URL'),
    'user'      => env('DA_USER'),
    'key'       => env('DA_LOGIN_KEY'),

    // ชั้นที่ 1 - ห้ามเขียนอะไรที่กระทบบัญชีลูกค้า (ปิดไว้ก่อน)
    'read_only' => env('DA_READ_ONLY', true),

    // ชั้นที่ 2 - อนุญาตให้ซิงก์แพ็ก แยกอิสระจากชั้นแรก (ปิดไว้ก่อน)
    'allow_package_sync' => env('DA_ALLOW_PACKAGE_SYNC', false),
],
private function assertSyncAllowed(): void
{
    if (! (bool) config('services.directadmin.allow_package_sync', false)) {
        throw new DirectAdminException(
            'การซิงก์แพ็กถูกปิดอยู่ ตั้ง DA_ALLOW_PACKAGE_SYNC=true ใน .env '
            . '(คนละตัวกับ DA_READ_ONLY) แล้วล้าง config cache ก่อนลองใหม่',
            'CMD_MANAGE_USER_PACKAGES',
        );
    }
}

Every public method calls this as its first line. I do not check it in the controller, because there are multiple controller entry points but only one service path. Work that touches customer accounts calls a separate guard that checks read_only. This means I can let admins manage packages while customer accounts remain locked, which is how I am running it right now.

The default must be deny, not allow

If I write env('DA_READ_ONLY', false) and someone later copies .env to a new machine but forgets that line, the system immediately allows unrestricted writes without anyone asking it to. Conversely, if the default is deny, someone complains that a button does not work, which can be fixed in thirty seconds. Those two mistakes have very different costs. Choose the path that fails without taking action.

Intermediate states and the traces to keep

Work that sends requests across machines has a period where "it has been sent, but the result is still unknown." I therefore write the status to the database before sending HTTP, not afterward. If the request hangs or someone presses stop partway through, the admin will see the status stuck at syncing and know to check the actual state on the panel. That is better than leaving the old status visible and giving the impression that nothing happened.

// ทำเครื่องหมายก่อนยิง ไม่ใช่หลังยิง
$product->forceFill([
    'da_sync_status' => 'syncing',
    'da_sync_error'  => null,
])->save();

For the audit log, I record both successes and failures, including the "values after the change," rather than just the word success. When a customer says their quota changed on its own, the useful answer is a record of who pressed the button, when, what the old value was, and what the new value is, rather than a guess.

# คำสั่งสำหรับไล่ตรวจจากบรรทัดคำสั่ง ก่อนเปิดสวิตช์ให้แอดมินใช้จริง
php artisan cb:da:list-packages          # อ่านอย่างเดียว ดูว่าเชื่อมต่อได้ไหม
php artisan cb:da:pull demo-basic        # ดึงค่าจริงลงฐาน ไม่เขียนกลับ
php artisan config:clear                 # แก้ .env แล้วต้องล้าง cache เสมอ
The rules I use for every form-driven operation

First, always send the complete form, reading and merging the existing values before sending. Second, never determine the result from the response content; read the actual state again. Third, separate kill switches by the extent of the damage and default to deny. Fourth, write the status before sending the request, not afterward. Fifth, always log unrecognized response bodies, because when the vendor changes the interface, that is the only evidence I have.

Personally, I consider driving forms this way an acceptable solution, but I must always remember that I am using something whose format the vendor has not promised to preserve. The day they update the panel and rename a field, my system will fail silently. Read-back verification and logs that preserve the response body are the two things that let me find out quickly instead of waiting for a customer to call. Anyone applying this to other endpoints on the same panel can follow the same rules. If the fields in someone's panel version differ from what I have written here, I will come back and update this later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first