This commit is contained in:
83
.gitea/workflows/release.yml
Normal file
83
.gitea/workflows/release.yml
Normal 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"
|
||||
@@ -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`.
|
||||
|
||||
211
src/pia_api_server.go
Normal file
211
src/pia_api_server.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user