CI/CD
All checks were successful
release / build-and-release (push) Successful in 3m23s

This commit is contained in:
2026-04-18 19:56:04 -05:00
parent dce590914b
commit 50e80cec2d
4 changed files with 299 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
name: release
on:
push:
branches:
- main
permissions:
contents: read
releases: write
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build executables
run: |
set -eu
mkdir -p dist
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o dist/pia-qbt-autoport-linux-amd64 ./src
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o dist/pia-qbt-autoport-windows-amd64.exe ./src
- name: Create release
id: create_release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }}
RELEASE_TAG: main-${{ github.run_number }}-${{ github.run_attempt }}-${{ github.sha }}
run: |
set -eu
short_sha="${GITHUB_SHA::7}"
payload=$(jq -n \
--arg tag_name "$RELEASE_TAG" \
--arg name "Main build ${GITHUB_RUN_NUMBER} (${short_sha})" \
--arg body "Automated release for commit ${GITHUB_SHA}." \
--arg target_commitish "${GITHUB_SHA}" \
'{tag_name:$tag_name,name:$name,body:$body,target_commitish:$target_commitish,draft:false,prerelease:false}')
response=$(curl -fsS \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$payload" \
"${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases")
release_id=$(printf '%s' "$response" | jq -r '.id')
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
- name: Upload Linux binary
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }}
RELEASE_ID: ${{ steps.create_release.outputs.release_id }}
run: |
set -eu
curl -fsS \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@dist/pia-qbt-autoport-linux-amd64" \
"${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=pia-qbt-autoport-linux-amd64"
- name: Upload Windows binary
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }}
RELEASE_ID: ${{ steps.create_release.outputs.release_id }}
run: |
set -eu
curl -fsS \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@dist/pia-qbt-autoport-windows-amd64.exe" \
"${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=pia-qbt-autoport-windows-amd64.exe"

View File

@@ -1 +1,3 @@
# PIA_QBT_AutoPort
The Gitea workflow in `.gitea/workflows/release.yml` builds Linux and Windows executables and publishes a release on every push to `main`.

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module pia_qbt_autoport
go 1.22

211
src/pia_api_server.go Normal file
View File

@@ -0,0 +1,211 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type Config struct {
APIKey string `json:"api_key"`
ListenAddr string `json:"listen_addr"`
PIACTLPath string `json:"piactl_path"`
}
type PIAInfo struct {
Timestamp string `json:"timestamp"`
ConnectionState string `json:"connection_state"`
VPNIP string `json:"vpn_ip"`
PortForward string `json:"port_forward"`
Region string `json:"region"`
Protocol string `json:"protocol"`
RequestPortForward string `json:"request_port_forward"`
Regions []string `json:"regions,omitempty"`
PIACTLVersion string `json:"piactl_version,omitempty"`
}
func main() {
exePath, err := os.Executable()
if err != nil {
log.Fatal(err)
}
baseDir := filepath.Dir(exePath)
configPath := filepath.Join(baseDir, "config.json")
cfg, created, err := loadOrCreateConfig(configPath)
if err != nil {
log.Fatal(err)
}
if created {
log.Printf("created config: %s", configPath)
log.Printf("api key: %s", cfg.APIKey)
}
mux := http.NewServeMux()
mux.HandleFunc("/pia", func(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
apiKey = r.URL.Query().Get("api_key")
}
if apiKey != cfg.APIKey {
writeJSON(w, http.StatusUnauthorized, map[string]any{
"error": "unauthorized",
})
return
}
info, err := collectPIAInfo(cfg.PIACTLPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, info)
})
server := &http.Server{
Addr: cfg.ListenAddr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("listening on %s", cfg.ListenAddr)
log.Printf("endpoint: GET /pia")
log.Fatal(server.ListenAndServe())
}
func loadOrCreateConfig(path string) (Config, bool, error) {
var cfg Config
if _, err := os.Stat(path); err == nil {
data, err := os.ReadFile(path)
if err != nil {
return cfg, false, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, false, err
}
if cfg.APIKey == "" {
return cfg, false, errors.New("config.json exists but api_key is empty")
}
if cfg.ListenAddr == "" {
cfg.ListenAddr = "127.0.0.1:8080"
}
if cfg.PIACTLPath == "" {
cfg.PIACTLPath = `C:\Program Files\Private Internet Access\piactl.exe`
}
return cfg, false, nil
} else if !os.IsNotExist(err) {
return cfg, false, err
}
key, err := generateAPIKey(32)
if err != nil {
return cfg, false, err
}
cfg = Config{
APIKey: key,
ListenAddr: "127.0.0.1:8080",
PIACTLPath: `C:\Program Files\Private Internet Access\piactl.exe`,
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return cfg, false, err
}
if err := os.WriteFile(path, data, 0600); err != nil {
return cfg, false, err
}
return cfg, true, nil
}
func generateAPIKey(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func collectPIAInfo(piactlPath string) (PIAInfo, error) {
info := PIAInfo{
Timestamp: time.Now().Format(time.RFC3339),
}
var errStrings []string
info.ConnectionState = safeGet(piactlPath, &errStrings, "get", "connectionstate")
info.VPNIP = safeGet(piactlPath, &errStrings, "get", "vpnip")
info.PortForward = safeGet(piactlPath, &errStrings, "get", "portforward")
info.Region = safeGet(piactlPath, &errStrings, "get", "region")
info.Protocol = safeGet(piactlPath, &errStrings, "get", "protocol")
info.RequestPortForward = safeGet(piactlPath, &errStrings, "get", "requestportforward")
version := safeGet(piactlPath, &errStrings, "--version")
if version != "" {
info.PIACTLVersion = version
}
regionsRaw := safeGet(piactlPath, &errStrings, "get", "regions")
if regionsRaw != "" {
parts := strings.FieldsFunc(regionsRaw, func(r rune) bool {
return r == '\n' || r == '\r' || r == ','
})
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
info.Regions = append(info.Regions, p)
}
}
}
if len(errStrings) > 0 && info.ConnectionState == "" && info.VPNIP == "" && info.PortForward == "" {
return info, errors.New(strings.Join(errStrings, " | "))
}
return info, nil
}
func safeGet(piactlPath string, errStrings *[]string, args ...string) string {
out, err := runPIACTL(piactlPath, args...)
if err != nil {
*errStrings = append(*errStrings, fmt.Sprintf("%s: %v", strings.Join(args, " "), err))
return ""
}
return out
}
func runPIACTL(piactlPath string, args ...string) (string, error) {
cmd := exec.Command(piactlPath, args...)
out, err := cmd.CombinedOutput()
text := strings.TrimSpace(string(out))
if err != nil {
if text == "" {
return "", err
}
return "", fmt.Errorf("%v: %s", err, text)
}
return text, nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}